Skip to content

feat(ClientController): init controller#7808

Merged
Kriys94 merged 7 commits intomainfrom
feature/ApplicationState
Feb 17, 2026
Merged

feat(ClientController): init controller#7808
Kriys94 merged 7 commits intomainfrom
feature/ApplicationState

Conversation

@Kriys94
Copy link
Contributor

@Kriys94 Kriys94 commented Feb 2, 2026

Explanation

Current state: Application lifecycle management (knowing when the UI is open or closed) is currently scattered across platform-specific code (Extension and Mobile). When the UI state changes, MetamaskController must manually notify each controller/service that cares about this state via imperative calls.

Problem: This approach doesn't scale well. Every time a new controller needs to respond to client state changes (stop polling, disconnect WebSockets, pause subscriptions), the platform code has to be modified to add another manual call. Platform code becomes tightly coupled to controller-specific behavior.

Solution: This PR introduces @metamask/ui-state-controller, a new shared controller that centralizes application lifecycle state. The pattern follows an inversion of control approach:

  1. Platform code calls a single messenger action: UiStateController:setUiOpen
  2. Consumer controllers subscribe to UiStateController:stateChange and manage themselves
  3. Platform code no longer needs controller-specific logic (e.g., starting/stopping polling, connecting/disconnecting WebSockets) — controllers handle this internally by reacting to state changes

This enables polling controllers to stop when the client closes, WebSocket connections to disconnect, and real-time subscriptions to pause—all without modifying platform code, with the logic encapsulated in core.

State properties:

  • isUiOpen: boolean — Whether the client (UI) is currently open (not persisted, always starts as false)

Messenger API:

  • Action: UiStateController:setUiOpen — Called by platform code
  • Event: UiStateController:stateChange — Subscribed to by consumer controllers

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Low Risk
Mostly additive new package and metadata/docs wiring with minimal impact on existing runtime behavior; risk is limited to new consumers depending on the new messenger action/event contract.

Overview
Introduces a new package, @metamask/client-controller, providing a ClientController that tracks whether the MetaMask UI is open (isUiOpen, non-persisted) and exposes a messenger action ClientController:setUiOpen plus the standard ClientController:stateChange event for consumers.

Adds accompanying exports (types + clientControllerSelectors.selectIsUiOpen), 100%-coverage unit tests, and package scaffolding (README, changelog, license, Jest/TypeDoc/TS configs, package.json). Updates monorepo wiring/metadata (tsconfig references, yarn.lock, CODEOWNERS, teams.json) and regenerates the root README.md dependency graph/package list content.

Written by Cursor Bugbot for commit 437f480. This will update automatically on new commits. Configure here.

@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch 4 times, most recently from c2404be to 7ff3a8a Compare February 2, 2026 15:02
@Kriys94 Kriys94 marked this pull request as ready for review February 2, 2026 15:48
@Kriys94 Kriys94 requested a review from a team as a code owner February 2, 2026 15:48
@mcmire
Copy link
Contributor

mcmire commented Feb 2, 2026

@Kriys94

I plan on reviewing this but first I want to better understand the problems you see this controller solving and how you anticipate it being used.

In the explanation for this PR, you say that as new controllers with data subscriptions are added, clients must be updated to inform those controllers to start when the UI is active and stop when the UI is not. You say that this isn't scalable because this code is scattered throughout the clients. Furthermore, in the technical proposal you created earlier, you seem to say that the UI should not be responsible for managing data subscriptions, the controller layer should. This is further implied in this PR when suggesting that with the existence of an ApplicationStateController, other controllers can now be aware of when the UI is open and automatically start and stop data subscriptions.

First, I don't think it is bad that there is some code in the UI which controls when a data subscription is started or stopped. After all, not every screen needs every piece of data we use throughout MetaMask, so it seems perfectly fine to me to place that control at the React component level (i.e., "when this component mounts, start polling or open a WebSocket; when this components unmounts, stop").

My sense is that the problem we're trying to solve here is not how to handle what happens if a user switches away from a screen, but how to handle when the app (whether that's the mobile app, or a tab in a browser) is backgrounded or foregrounded. Here, the user is still on a screen — so no component is unmounted — but the user is not actively using the app. And in this case, it seems to me that if the current screen is actively polling for data or has a WebSocket open, then that data subscription needs to be paused, so that when the user returns, it can be resumed.

In other words, alongside this controller I wonder if we ought to update the pattern that we've been using so that polling controllers, WebSocket services, and other places have both a pause and a resume method in addition to start and stop. (resume would be like start, but the subscription would only be activated if it was previously paused; otherwise nothing would happen. And pause would merely track the state of the subscription before stopping.)

I think reframing this controller — and the examples presented in places like the README — in this way would make more sense to me, but what do you think?

@Kriys94
Copy link
Contributor Author

Kriys94 commented Feb 3, 2026

First, I don't think it is bad that there is some code in the UI which controls when a data subscription is started or stopped. After all, not every screen needs every piece of data we use throughout MetaMask, so it seems perfectly fine to me to place that control at the React component level (i.e., "when this component mounts, start polling or open a WebSocket; when this components unmounts, stop").

Currently, when you look at the core codebase, you don't have a complete picture of how/when some code is executed. This is extremely hard to understand the whole workflow when you don't know that some logic lives in the UI.
This is especially useful when the application state is used in multiple places.

To me, it's the same utility as when we switch accounts: we have a state and events that we can listen to in any Controller, with no UI code/hooks needed. Thanks to this App State Event, we could consider removing all polling logic from the extension and mobile, which represents quite some code to clean, but ultimately will make platform code lighter.

@mcmire
Copy link
Contributor

mcmire commented Feb 4, 2026

.@Kriys94 and I spoke earlier today about this. For completeness here is more or less a summary of what I said:

Implications on polling controllers

To be clear, I don't think this controller is not needed. I can see the value in encapsulating the concept of whether the user is currently using MetaMask so that we can access that information in an agnostic fashion.

However, there are caveats around how this controller ought to be used that I think should be highlighted or at least addressed somewhere. Namely, when it comes to polling controllers, I don't know that we would want to suggest that engineers can update their controllers to start polling when MetaMask is active and stop polling when it's inactive, e.g.:

this.#messenger.subscribe(
  'ApplicationStateController:stateChange',
  (isClientOpen) => {
    if (isClientOpen) {
      this.#start();
    } else {
      this.#stop();
    }
  },
  (state) => state.isClientOpen
);

This pattern implies that we would be moving polling management to a more global location: as soon as MetaMask opens, no matter what screen the user is on, we start subscribing to updates for all kinds of data — even for ones that the current screen doesn't need. I don't feel like this is a good strategy long-term. We've gotten in trouble in the past about making network requests before users complete onboarding and I feel like we would run into that again if we went that direction.

That's why I suggested continuing to have the UI drive which polls are active, but then introducing the idea of "pausing" and "resuming", for instance:

this.#messenger.subscribe(
  'ApplicationStateController:stateChange',
  (isClientOpen) => {
    if (isClientOpen) {
      this.#resume();
    } else {
      this.#pause();
    }
  },
  (state) => state.isClientOpen
);

If we are providing examples for how to use this controller in this PR, then I think we should suggest that engineers follow these practices.

Naming

The other thing I mentioned in the call is around naming:

  1. The extension repo already has an AppStateController, so introducing an ApplicationStateController could get confusing.
  2. The state property is called isClientOpen. This could be possibly inconsistent and confusing depending on perspective.

I think the name ApplicationStateController is fine. It's a bit generic, but I can imagine having one or two other state properties that we might want to track that don't really belong anywhere else. Maybe we can plan to rename AppStateController in extension to ExtensionApplicationStateController or something, though?

I also wonder if we should rename the state property? I am fine with the word "open". On extension, it could reasonably mean that the popup is open, or the full-screen view is open (regardless of whether the tab is active), or the sidepanel is open. And on mobile, it would mean that the app is foregrounded. But, "client" seems like a synonym for "application" to me. So maybe we should rename the state property either isApplicationOpen or isUiOpen?

@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch from 876c111 to 3620a98 Compare February 5, 2026 14:13
@Kriys94
Copy link
Contributor Author

Kriys94 commented Feb 5, 2026

@mcmire For the naming, I called it ClientStateController. WDYT?

@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch 12 times, most recently from 7dff417 to 4f0fa7d Compare February 6, 2026 15:39
@Kriys94 Kriys94 changed the title feat(ApplicationState): init controller feat(UiStateController): init controller Feb 6, 2026
@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch from 7afa7ef to 47c96d0 Compare February 10, 2026 22:12
@Kriys94 Kriys94 changed the title feat(UiStateController): init controller feat(ClientController): init controller Feb 10, 2026
@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch 3 times, most recently from a646de4 to 1a2635c Compare February 10, 2026 22:42
Cal-L
Cal-L previously approved these changes Feb 11, 2026
Copy link
Contributor

@Cal-L Cal-L left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed two more changes (not sure if they were present before or not). After this it should be good to go from me.

@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch 3 times, most recently from 407a697 to 73385cb Compare February 17, 2026 10:39
@Kriys94 Kriys94 requested a review from mcmire February 17, 2026 10:40
@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch from 73385cb to f0a8653 Compare February 17, 2026 10:42
@socket-security
Copy link

socket-security bot commented Feb 17, 2026

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch from f0a8653 to d21efe7 Compare February 17, 2026 10:51
@Kriys94 Kriys94 force-pushed the feature/ApplicationState branch from d21efe7 to 437f480 Compare February 17, 2026 10:59
Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@Kriys94 Kriys94 added this pull request to the merge queue Feb 17, 2026
Merged via the queue into main with commit 7cf4ca7 Feb 17, 2026
306 checks passed
@Kriys94 Kriys94 deleted the feature/ApplicationState branch February 17, 2026 17:06
khanti42 pushed a commit that referenced this pull request Feb 20, 2026
## Explanation

**Current state:** Application lifecycle management (knowing when the UI
is open or closed) is currently scattered across platform-specific code
(Extension and Mobile). When the UI state changes, MetamaskController
must manually notify each controller/service that cares about this state
via imperative calls.

**Problem:** This approach doesn't scale well. Every time a new
controller needs to respond to client state changes (stop polling,
disconnect WebSockets, pause subscriptions), the platform code has to be
modified to add another manual call. Platform code becomes tightly
coupled to controller-specific behavior.

**Solution:** This PR introduces `@metamask/ui-state-controller`, a new
shared controller that centralizes application lifecycle state. The
pattern follows an inversion of control approach:

1. Platform code calls a single messenger action:
`UiStateController:setUiOpen`
2. Consumer controllers subscribe to `UiStateController:stateChange` and
manage themselves
3. **Platform code no longer needs controller-specific logic** (e.g.,
starting/stopping polling, connecting/disconnecting WebSockets) —
controllers handle this internally by reacting to state changes

This enables polling controllers to stop when the client closes,
WebSocket connections to disconnect, and real-time subscriptions to
pause—all without modifying platform code, with the logic encapsulated
in core.

**State properties:**
- `isUiOpen: boolean` — Whether the client (UI) is currently open (not
persisted, always starts as `false`)

**Messenger API:**
- Action: `UiStateController:setUiOpen` — Called by platform code
- Event: `UiStateController:stateChange` — Subscribed to by consumer
controllers

## References

- Related Extension PR:
MetaMask/metamask-extension#39703
- Related Mobile PR:
MetaMask/metamask-mobile#25517

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [ ] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Mostly additive new package and metadata/docs wiring with minimal
impact on existing runtime behavior; risk is limited to new consumers
depending on the new messenger action/event contract.
> 
> **Overview**
> Introduces a new package, `@metamask/client-controller`, providing a
`ClientController` that tracks whether the MetaMask UI is open
(`isUiOpen`, non-persisted) and exposes a messenger action
`ClientController:setUiOpen` plus the standard
`ClientController:stateChange` event for consumers.
> 
> Adds accompanying exports (types +
`clientControllerSelectors.selectIsUiOpen`), 100%-coverage unit tests,
and package scaffolding (README, changelog, license, Jest/TypeDoc/TS
configs, `package.json`). Updates monorepo wiring/metadata (`tsconfig`
references, `yarn.lock`, `CODEOWNERS`, `teams.json`) and regenerates the
root `README.md` dependency graph/package list content.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
437f480. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants